iT邦幫忙

2021 iThome 鐵人賽

2

在先前的文章中,我們已經探討過:

  • 中斷與異常的處理
  • UNIX-Like Shell 的實作方式

在本篇文章中,作者會嘗試實作基本的系統呼叫以及 Shell 在 mini-riscv-os 當中。

透過 Shell 學習 fork 與 exit 等系統呼叫

系統呼叫 (System Call) 由作業系統提供,若 User space 的應用程式調用了系統呼叫,作業系統便會從 User space 切換至 Kernel space,待作業系統處理完該系統呼叫後才會切換回 User space。

上圖取自 Uppsala University 的作業系統教材

以上是一個最精簡的 UNIX Shell 的流程圖,Shell 會讀取來自使用者輸入的命令,並且呼叫 fork() 讓作業系統複製一個狀態與母程序一樣的子程序。

#include <unistd.h>
pid_t fork(void);

On success, the PID of the child process is returned in the parent, and 0 is returned in the child.
-- Linux manual page

子程序被作業系統產生後,母與子程序會根據 fork() 的 return value 去判斷當前的程序是前者還是後者:

  • 若執行的為母程序,則呼叫 wait() 系統呼叫等待子程序結束執行。
  • 若執行的為子程序,則將剛剛所分析完的使用者命令帶入 exec() 系統呼叫,載入並執行指定的檔案。
  • 當子程序結束後,母程序會進入下一個循環等待使用者下達新的命令。

補充 Copy On Write

當作業系統接受到 fork() 系統呼叫後,會將母程序的內容複製並產生子程序。
由於母程序與子程序所持有的 Stack 並非同一塊,所以,若要在處理 fork() 時就完整的 Copy 一整份 Stack 的內容是非常浪費效能的。
針對此問題,許多作業系統都採用 Copy On Write 的技術,假設母程序在 Stack 上存放了一個 counter 變數,呼叫 fork() 產生子程序後,兩者其實都是參考到同一塊記憶體上的。這樣的狀況會一直持續到其中一方嘗試修改 counter 變數,修改方會將 counter 的內容放到新的記憶體位址後再修改它所持有的那一份。這麼做就可以節省記憶體空間以及 fork() 所帶來的額外開銷!

進入正題:來實作系統呼叫與簡易的 Shell 吧!

注意!
目前尚未實作 fork() 系統呼叫,下方若出現它的身影可以先略過。

實作系統呼叫

剛剛有提到:

若 User space 的應用程式調用了系統呼叫,作業系統便會從 User space 切換至 Kernel space。

要滿足這樣的需求,我們可以使用系統中斷:

看到 Interrupt = 0 & Exception = 11 所對應到的描述 Environment call from M-mode,要產生 Environment call 只要使用 ecall 即可。
因此,我們使用組合語言去實現幾個函式提供 C 語言呼叫:

.global gethid
gethid:
	li a7, 1
	ecall
	ret
.global fork
fork:
	li a7, 2
	ecall
	ret
.global exec
exec:
	li a7, 3
	ecall
	ret
.global exit
exit:
	li a7, 4
	ecall
	ret

只要呼叫上面所列出的任一個函式,這些函式都會將其系統呼叫對應到的號碼放入 a7 暫存器後再執行 ecall

/* In trap_handler() */
    case 11:
        lib_puts("Environment call from M-mode!\n");
        do_syscall(ctx, &return_pc);
        break;

從 Machin mode 產生的 Environment call 會交由 do_syscall() 處理:

void do_syscall(struct context *ctx, uint32_t *pc)
{
	uint32_t syscall_num = ctx->a7;
	int ppid = get_current_task();
	debug_lib_puts("syscall_num: %d\n", syscall_num);
	switch (syscall_num)
	{
	case 1:
		ctx->a0 = sys_gethid((unsigned int *)(ctx->a0));
		break;
	case 2:
		/* fork
		 * returned value:
		 *	child process: 0
		 *  parent process: pid of child process
		 */
		ctx->a0 = task_copy(ppid, pc);
		break;
	case 3:
		// exec
		ctx->a0 = sys_exec((char *)ctx->a0, pc);
		goto ret;
		break;
	case 4:
		// exit
		sys_exit();
		*pc = &os_kernel;
		goto ret;
		break;
	default:
		lib_printf("Unknown syscall no: %d\n", syscall_num);
		ctx->a0 = -1;
	}

	*pc = *pc + 4;
ret:
	return;
}

do_syscall() 會以 a7 暫存器所儲存的號碼判斷系統呼叫,並做出相對的處理,這邊以 exec()exit() 為例:

  • exec() 被呼叫,系統會將希望執行的程式位址作為參數傳入覆寫掉 pc 的位址。
  • exit() 被呼叫,系統會把該 Task 移除並將 pc 複寫為 os_kernel 的位址。

會將希望執行的程式位址作為參數傳入 exec() 函式是因為該作業系統並沒有完整的檔案系統實作,所以沒有所謂可執行檔案的概念。
為了彌補這個缺陷,我選擇事先構造 app table,讓作業系統在開機執行 user_init() 時可以先註冊基本的 User Program:

void user_init()
{
	user_app_init();
	user_app_register("info", &show_info);
	user_app_register("clear", &clear);
	task_create(&sh);
}

當然,這些 Program 也只是簡易的函式,然後透過函式指標的方式註冊到 OS。

void show_info()
{
	lib_puts("Wellcome to toothpasteOS\n");
	lib_puts("Version: 1.0\n");
	lib_puts("Note: Derived from mini-riscv-os\n");
	lib_puts("Author: Ian Chen\n");
	exit();
}

實作 mini shell

在實作 mini shell 之前,筆者其實也發現了一些問題:

  • 若在 lib_puts() 執行時(也就是使用者還沒輸入命令給 Shell 之前)發生時間中斷,等到處理完中斷再回到該 Task 時就會無法正確執行。
  • 若不小心存取到不存在的記憶體空間,作業系統會不斷出現 Fault load 或是 Fault store 的狀況。
  • 因為系統還沒實作 fork 跟 file system,所以不可能把 unix shell 的那一套方法直接拿來用。

這些問題筆者也花了一點時間得到了(可能不是非常好的)解答:

  1. lib_puts() 執行時前後需特別關閉/開啟 interrupt enable:
/* Disable timer interrupt*/
w_mie(r_mie() & ~(1 << 7));
/* Enable timer interrupt*/
w_mie(r_mie() | MIE_MTIE);

BTW,請先將 exteral_handler() 中處理 UART 中斷的 handler 移除,不然在使用者輸入字元的當下系統會直接把 uart register 的內容印出來,等到 shell 要用的時候就已經讀不到內容了。

  1. 讓 Program counter 執行下一條指令
    如果中斷與異常處理完成後,Program counter 還是繼續執行發生異常的指令,就有可能會造成死循環,因此,只要稍微修改 trap_handler() 即可避免該問題:
case 5:
      // Fault load!
      return_pc = return_pc + 4;
      break;
case 7:
      // Fault store!
      return_pc = return_pc + 4;
      break;
  1. 改用 task_create() 替代
    剛剛有提到筆者構造了一個 user table 用來存放一些簡易的 user program。因此,我們只要在讀取命令以後找到命令所對應的函式,再丟給 task_create() 就可以當作 fork() 的替代方案啦!
#include "os.h"
void sh()
{
    char input[50];
    int ready = 1;
    while (ready)
    {
        lib_puts("$ ");
        lib_gets(input);
        ready = 0;
        user_app_t *app_table = get_app_table();
        int i = 0;
        for (; i < APP_NUM; i++)
        {
            if (strcmp(app_table[i].path, input) >= 0)
            {
                break;
            }
        }
        if (i == APP_NUM)
        {
            lib_printf("shell: %s: command not found.\n", input);
        }
        else if (task_create(app_table[i].task) > 0)
        {
            lib_printf("shell: task[%s] is created! \n", app_table[i].path);
        }
        else
        {
            lib_printf("Only allow 30 tasks to run simultaneously.\n");
        }
        lib_delay(1000);
        ready = 1;
        w_mie(r_mie() | MIE_MTIE);
    }
}

此外,為了確保使用者在輸入命令後,系統可以立刻處理其需求,我縮短了作業系統排程切換的時間並加入 lib_delay(),以保證系統會馬上切換到 user program 進行處理。

總結

這次的實驗讓使用者能夠體驗與 mini-riscv-os 互動的快樂(?)
不過這些更動其實還沒 patch-back 回 mini-riscv-os 之中,而是修改在我的牙膏 OS 專案底下,主要原因有幾點:

  • 改動的幅度有點大。
  • 已實現的系統呼叫並沒有被 shell 完整的運用。
  • 應改以其他方式實現任務的排程,而非使用 Timer Interrupt。
  • 在處理 UART 中斷的當下應該把 uart register 的內容 buffer 起來,等到 lib_puts() 要用時再存取 buffer 就好。

即使該專案仍有諸多缺點,但作為一個學習作業系統的敲門磚,我相信它還是有其價值存在,也歡迎各位賞我的牙膏 OS 一個 Star。
最後,也感謝 austin362667 貢獻了 gethid() 的實作,構建了系統呼叫的雛形。

Reference


上一篇
goto die? 那個 goto 到底能不能用啊?
系列文
微自幹的作業系統輕旅行41
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
DanSnow
iT邦好手 1 級 ‧ 2022-01-31 14:39:24

若執行的為子程序,則將剛剛所分析完的使用者命令帶入 exit() 系統呼叫,載入並執行指定的檔案

這邊應該是 exec

EN iT邦好手 1 級 ‧ 2022-01-31 16:58:25 檢舉

感謝提醒!

我要留言

立即登入留言